Skip to content

feat(jwt): add externalId and requiresJwt to Operation base class (2/6)#2624

Merged
nan-li merged 10 commits intofeat/identity_verification_feature_flagged_major_releasefrom
feat/iv-oprepo-gating-02
May 7, 2026
Merged

feat(jwt): add externalId and requiresJwt to Operation base class (2/6)#2624
nan-li merged 10 commits intofeat/identity_verification_feature_flagged_major_releasefrom
feat/iv-oprepo-gating-02

Conversation

@nan-li
Copy link
Copy Markdown
Contributor

@nan-li nan-li commented Apr 24, 2026

Description

One Line Summary

PR 2 of 6 against the Identity Verification integration branch — mechanical refactor that adds externalId: String? and requiresJwt: Boolean to the Operation base class so the next PR can implement queue-level IV gating. No runtime behavior change.

Details

Motivation

The next PR in the series (PR 3/6, "queue-level IV") needs to read op.externalId polymorphically from OperationRepo. Today externalId is declared on LoginUserOperation and TrackCustomEventOperation only — OperationRepo can't read it from a generic Operation reference without casting to each of the 16 subclass types. This PR hoists the property (and a companion requiresJwt flag) to the base class so downstream consumers can read them uniformly.

Kept deliberately narrow so the base-class refactor has its own reviewable surface; the behavior changes (pre-HYDRATE deferral, FAIL_UNAUTHORIZED handling, IdentityVerificationService with anon-op purge) come in the next PR.

Scope

Operation base class:

  • New var externalId: String? — captures the externalId of the user the op was enqueued for. Nullable because anonymous users have no externalId; base-class declaration keeps the type String? uniformly across all subclasses.
  • New open val requiresJwt: Boolean get() = true — declares whether an op's backend endpoint needs a JWT when IV is active. Subclasses may override to false for endpoints that don't require auth (none do yet — default true covers every current op).

Operation subclasses (16):

  • All 16 constructors gain externalId: String? as the 3rd parameter (after appId and onesignalId).
  • LoginUserOperation and TrackCustomEventOperation shed their local externalId property in favor of the inherited base-class property (previously defined on each subclass independently).
  • TransferSubscriptionOperation is the one exception in parameter position — its constructor is (appId, subscriptionId, onesignalId, externalId) to match the existing layout where onesignalId comes after subscriptionId.

Call sites updated to pass externalId at construction time (10 files):

  • IdentityModelStoreListener, PropertiesModelStoreListener, SubscriptionModelStoreListener — read _identityModelStore.model.externalId.
  • SessionListener (session start + end).
  • TrackGooglePurchase (purchase tracking).
  • UserSwitcher (legacy player ID migration).
  • RebuildUserService (reconstructs queued ops from cached models).
  • UserRefreshService (foreground user refresh).
  • LoginUserOperationExecutor, LoginUserFromSubscriptionOperationExecutor, SubscriptionOperationExecutor (ops they emit as side effects, e.g. post-login RefreshUserOperation, post-subscription-recreate CreateSubscriptionOperation).

PropertiesModelStoreListener gains IdentityModelStore as a constructor dependency — the properties model doesn't carry externalId, so the listener has to read it from the identity model.

Placement decision: base class vs per-subclass

onesignalId is declared on each subclass individually, not on the base. Considered matching that convention for externalId/requiresJwt, but chose base-class placement:

  • externalId is naturally nullable; String? works uniformly across every subclass without weakening any type.
  • Cross-cutting consumers (next PR's hasValidJwtIfRequired, removeOperationsWithoutExternalId) need to read op.externalId polymorphically — that requires at least an abstract declaration on the base. Per-subclass with abstract-on-base would mean ~80 lines of boilerplate (override + getter/setter × 16 subclasses) vs the 5-line base-class declaration.
  • requiresJwt has natural override semantics (open val with default), which is idiomatic on a base class.

name on Operation is precedent for a non-nullable universal property on the base, so there's no technical constraint keeping onesignalId off the base — that placement is a historical style preference that this PR does not touch.

What is NOT in this PR

  • IdentityVerificationService — PR 3/6.
  • OperationRepo IV gating (hasValidJwtIfRequired dispatch, FAIL_UNAUTHORIZED handler, pre-HYDRATE deferral in getNextOps) — PR 3/6.
  • HTTP layer JWT attachment — PR 4/6.
  • Public API (login(externalId, jwt), updateUserJwt, listeners) — PR 5/6.
  • Logout + IAM integration — PR 6/6.

Testing

Unit testing

  • Compile verified — every call site updated to pass externalId (see compile errors list narrowed from 50+ to 0).
  • All existing test fixtures updated to the new constructor signature. Most were mechanical substitutions (add null as 3rd arg for anon/un-scoped tests; add the relevant externalId where the test exercises it).
  • 791 core tests run, 789 pass, 2 fail — both are pre-existing SDKInitTests failures on the integration branch (unrelated: one is about an error-message assertion, the other about init not blocking). Same two failures exist on feat/iv-foundation-01 without this PR.

Manual testing

Not applicable — this PR adds a property + parameter with no runtime behavior change. Every call site still passes identically-shaped data; the only difference is that externalId is now available for polymorphic reads in the next PR.

Affected code checklist

  • Notifications
  • Outcomes
  • Sessions (session listener passes externalId to track-session ops)
  • In-App Messaging
  • REST API requests (only op shape; no new network traffic)
  • Public API changes

Checklist

Overview

  • I have filled out all REQUIRED sections above
  • PR does one thing — adds two properties to Operation and updates every subclass + call site
  • Any Public API changes are explained in the PR details and conform to existing APIs (no public API changes)

Testing

  • I have included test coverage for these changes (existing tests cover via construction; no new behavior to test in this PR)
  • All automated tests pass locally (pre-existing SDKInitTests failures are unrelated — see Manual testing)
  • Manual testing not applicable — no runtime behavior change

Final pass

  • Code is as readable as possible.
  • I have reviewed this PR myself, ensuring it meets each checklist item

@nan-li nan-li changed the title feat(iv): add externalId and requiresJwt to Operation base class feat(iv): add externalId and requiresJwt to Operation base class (2/6) Apr 24, 2026
@nan-li nan-li force-pushed the feat/iv-oprepo-gating-02 branch from 0caf0f3 to aec3996 Compare April 24, 2026 19:55
@nan-li
Copy link
Copy Markdown
Contributor Author

nan-li commented Apr 24, 2026

@claude review

@nan-li nan-li force-pushed the feat/iv-foundation-01 branch 3 times, most recently from 9a3c903 to 5835891 Compare April 27, 2026 23:06
nan-li added a commit that referenced this pull request Apr 27, 2026
…criptionId KDoc

Two PR review nits on #2624:

- Operation.externalId.set was implicitly public after the hoist from
  LoginUserOperation/TrackCustomEventOperation (both of which had
  `private set` before). Make it `internal set`, matching the precedent
  on LoginUserOperation.existingOnesignalId. Preserves the tripwire
  against accidental post-construction mutation that the new KDoc
  ("captured at construction time") promises.

- LoginUserFromSubscriptionOperation.subscriptionId had a copy-pasted
  "optional external ID..." KDoc that became actively misleading once
  externalId was hoisted to the base class — readers now see an
  external-ID description above subscriptionId with the real externalId
  silently inherited. Replace with an accurate description.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nan-li nan-li force-pushed the feat/iv-oprepo-gating-02 branch from aec3996 to 3c2e5f9 Compare April 27, 2026 23:09
nan-li added a commit that referenced this pull request Apr 27, 2026
…criptionId KDoc

Two PR review nits on #2624:

- Operation.externalId.set was implicitly public after the hoist from
  LoginUserOperation/TrackCustomEventOperation (both of which had
  `private set` before). Make it `internal set`, matching the precedent
  on LoginUserOperation.existingOnesignalId. Preserves the tripwire
  against accidental post-construction mutation that the new KDoc
  ("captured at construction time") promises.

- LoginUserFromSubscriptionOperation.subscriptionId had a copy-pasted
  "optional external ID..." KDoc that became actively misleading once
  externalId was hoisted to the base class — readers now see an
  external-ID description above subscriptionId with the real externalId
  silently inherited. Replace with an accurate description.
@nan-li nan-li force-pushed the feat/iv-oprepo-gating-02 branch from 3c2e5f9 to e5a77f9 Compare April 27, 2026 23:13
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both prior nits addressed (tightened setter visibility and fixed the subscriptionId KDoc) — mechanical refactor looks good.

Extended reasoning...

Overview

This is PR 2/6 of the Identity Verification series. It hoists two properties to the Operation base class — externalId: String? and open val requiresJwt: Boolean = true — and threads externalId through the constructors of all 16 Operation subclasses plus their ~10 call sites. LoginUserOperation and TrackCustomEventOperation shed their local externalId declarations in favor of the inherited base-class property. Tests are updated mechanically to the new constructor signatures.

Security risks

None introduced by this PR. The change is a structural refactor; it does not touch HTTP/auth/crypto code paths or change what data is sent to the backend. The new externalId field on operations is sourced from the same IdentityModelStore that already populated it on LoginUserOperation/TrackCustomEventOperation. The IV gating logic that will consume these properties is explicitly deferred to PR 3/6.

Level of scrutiny

Low-to-moderate. The diff is wide (34 files) but each touched site is mechanical — adding an externalId parameter at a fixed position and forwarding _identityModelStore.model.externalId. The two non-mechanical bits (introducing the new base-class property, and adding IdentityModelStore as a dep on PropertiesModelStoreListener) are localized and straightforward. There is no runtime behavior change in this PR.

Other factors

  • Both prior inline comments from my earlier review were resolved by recent commits (internal set on the setter, corrected subscriptionId KDoc).
  • Existing test coverage was preserved — every call site is exercised through compile-time wiring.
  • The PR is part of a 6-PR series, deliberately kept narrow so the base-class hoist has its own reviewable surface before downstream IV behavior lands.

nan-li and others added 7 commits May 3, 2026 19:54
…entry

First of set of PRs against the IV integration branch. Lands only the foundation
primitives; no consumer wiring or public API yet.

- Add `FeatureFlag.IDENTITY_VERIFICATION` (IMMEDIATE) so a Turbine kill-switch
  takes effect without a cold start.
- Add `IdentityVerificationGates` singleton (mirrors `ThreadingMode`) holding
  `newCodePathsRun` (featureFlag || jwt_required) and `ivBehaviorActive`
  (jwt_required alone). `FeatureManager.applySideEffects` pushes both values
  in on every refresh, reading from the passed `model` parameter to stay in
  sync with the HYDRATE snapshot.
- Add `JwtTokenStore` (SharedPreferences-backed externalId -> JWT) with an
  `EventProducer<IJwtUpdateListener>` for PR 5's IAM retry to subscribe to.
- Change `ConfigModel.useIdentityVerification` from `Boolean` to `Boolean?`;
  `null` means pre-HYDRATE so PR 2's `getNextOps` deferral can distinguish
  it from an explicit `false`.
- Add `PREFS_OS_JWT_TOKENS` key.
- Register `JwtTokenStore` in DI. `IdentityVerificationService` is deferred
  to PR 2 where its HYDRATE handler has real work.

Tests cover the gate matrix (including the ERROR STATE row where
jwt_required=true overrides a false feature flag), JWT store put/get/
invalidate/prune + listener broadcast, and FeatureManager propagation to
the gates on init and on HYDRATE.
…dering

JwtTokenStore fires update events after releasing the synchronized block
(matching the codebase's ModelStore pattern). With two concurrent writers
on the same externalId, event delivery order isn't guaranteed to match
mutation order — so a subscriber reading the event payload could see a
stale token.

Changed [IJwtUpdateListener] to a pure wake-up (`onJwtUpdated(externalId)`);
subscribers must call [JwtTokenStore.getJwt] to read the current value.
This matches the codebase's "events as wake-ups" convention (e.g. ModelStore
handlers re-read after notification) and removes the attractive nuisance
before PR 5's IAM retry is written against it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`ConfigModel.useIdentityVerification` was `Boolean?` where `null` silently
meant "pre-HYDRATE, not yet known" — easy to miss and easy to confuse with
the SDK-side `FeatureFlag.IDENTITY_VERIFICATION`. Replace with a tri-state
`JwtRequirement` enum (UNKNOWN / NOT_REQUIRED / REQUIRED) that names the
customer-config side explicitly and ties to the backend param (`jwt_required`).

- New `JwtRequirement` with a `fromBoolean(Boolean?)` helper so
  `ConfigModelStoreListener` can map the backend's nullable boolean.
- `ConfigModel.useIdentityVerification` is now `internal var … : JwtRequirement`
  (internal because the type is internal-only). Backing storage is unchanged —
  still a nullable boolean under the hood, so no cache migration needed.
- `IdentityVerificationGates.update` takes `jwtRequirement: JwtRequirement`;
  `required` is derived locally as `== REQUIRED`.
- Tests updated to use enum values.
`IdentityVerificationGates.update` previously wrote the two `@Volatile` fields
sequentially. On a `(true, true) → (false, false)` downgrade, a reader could
observe `(newCodePathsRun=false, ivBehaviorActive=true)` between the writes,
violating the documented invariant that `ivBehaviorActive=true` implies
`newCodePathsRun=true`.

Drop `newCodePathsRun` as a stored `@Volatile` field and compute it on read
from the two stored inputs (`_featureFlagOn` and `ivBehaviorActive`). The
invariant holds algebraically at every observation — `X || Y >= Y` — so
consumers can read either field (or both, in any order) without observing
an inconsistent state. Writer still performs two volatile stores but their
ordering no longer matters for the documented invariant.

Per-field access stays identical for consumers; `newCodePathsRun` is now
a computed property (two volatile reads + a boolean OR) but the perf delta
is negligible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ify gate logging

- FeatureFlag.IDENTITY_VERIFICATION -> SDK_IDENTITY_VERIFICATION; remote
  key "identity_verification" -> "sdk_identity_verification". Mirrors
  the SDK_BACKGROUND_THREADING naming convention and clarifies that this
  is the SDK-side rollout switch (vs. customer-side jwt_required).
- IdentityVerificationGates.update: drop the changed-vs-unchanged
  branching and the previousNewCode/previousIvActive temporaries that
  existed only to pick info vs debug log level. Single debug line now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
JwtRequirement is tri-state (UNKNOWN / NOT_REQUIRED / REQUIRED) but the
gate previously collapsed it to a Boolean ivBehaviorActive defaulting to
false, conflating "we don't know yet" with "customer opted out." All
current consumers are downstream of OperationRepo's pre-HYDRATE gate
(`isInitializedWithRemote`) so the asymmetry is latent today, but it's
a footgun for any future consumer that reads the gate without that
upstream guard.

Make jwtRequirement the stored field (defaults UNKNOWN); derive
ivBehaviorActive (`== REQUIRED`) and newCodePathsRun on read. UNKNOWN
reads as false for both Booleans, preserving observable behavior at all
existing call sites. Future consumers that need the tri-state distinction
can read jwtRequirement directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@nan-li nan-li force-pushed the feat/iv-foundation-01 branch from 5835891 to fea5a86 Compare May 4, 2026 02:55
@nan-li nan-li force-pushed the feat/iv-foundation-01 branch from fea5a86 to 0fb5092 Compare May 4, 2026 03:26
nan-li added 2 commits May 3, 2026 21:50
PR 2 of 6 — prerequisite refactor for queue-level IV behavior.

`externalId: String?` moves onto [Operation] as a protected property set at
construction time. Captures the user the op was enqueued for so the op stays
bound to that user even if the current user changes before execution.
`requiresJwt: Boolean` (default `true`) declares whether an op's backend
endpoint needs a JWT when Identity Verification is active; subclasses can
override to `false` for endpoints that don't require auth.

Every operation subclass constructor gains an `externalId: String?` parameter
as the third arg (after appId + onesignalId). Every call site that creates
an operation — model-store listeners, session listener, executors that build
result ops, RebuildUserService, UserRefreshService, etc. — now reads
`_identityModelStore.model.externalId` and passes it in. `LoginUserOperation`
and `TrackCustomEventOperation` previously had their own local `externalId`
property; those are removed in favor of the inherited base-class property.

No runtime behavior change in this PR. Queue-level IV work (pre-HYDRATE
deferral, FAIL_UNAUTHORIZED handler, IdentityVerificationService with anon
op purge) lands in PR 2.5.
…criptionId KDoc

Two PR review nits on #2624:

- Operation.externalId.set was implicitly public after the hoist from
  LoginUserOperation/TrackCustomEventOperation (both of which had
  `private set` before). Make it `internal set`, matching the precedent
  on LoginUserOperation.existingOnesignalId. Preserves the tripwire
  against accidental post-construction mutation that the new KDoc
  ("captured at construction time") promises.

- LoginUserFromSubscriptionOperation.subscriptionId had a copy-pasted
  "optional external ID..." KDoc that became actively misleading once
  externalId was hoisted to the base class — readers now see an
  external-ID description above subscriptionId with the real externalId
  silently inherited. Replace with an accurate description.
@nan-li nan-li force-pushed the feat/iv-oprepo-gating-02 branch from e5a77f9 to 8c5e762 Compare May 4, 2026 04:50
Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prior concerns from my last review were addressed in 8c5e762 (internal set on Operation.externalId; corrected subscriptionId KDoc); rest is a mechanical externalId-threading refactor with no runtime behavior change.

Extended reasoning...

Overview

This PR (2 of 6 in the Identity Verification series) hoists two properties onto the Operation base class — externalId: String? and open val requiresJwt: Boolean — and threads externalId through every subclass constructor (16 subclasses) plus every call site (10 producer files: listeners, executors, services, UserSwitcher, RebuildUserService, SessionListener, TrackGooglePurchase, UserRefreshService). PropertiesModelStoreListener gains IdentityModelStore as a constructor dependency to read externalId. Existing test fixtures across 6 test files are updated to the new constructor signature with null substitutions for anonymous cases.

Security risks

None directly introduced by this PR. The new externalId and requiresJwt fields are scaffolding — no consumer reads them yet. The actual IV gating (hasValidJwtIfRequired, FAIL_UNAUTHORIZED handling, pre-HYDRATE deferral, anon-op purge) lands in PR 3/6, and the HTTP JWT attachment in PR 4/6. Setter visibility on the new externalId property is internal set (matching existingOnesignalId precedent in LoginUserOperation.kt:42), preserving the "captured at construction" invariant for same-module subclass constructors.

Level of scrutiny

Low-medium. The change is mechanical and the runtime delta is zero — every call site that constructs an Operation now also passes the externalId from the same identity model that was already accessible to it, and no consumer reads externalId polymorphically yet. The risk surface is essentially "did all 26 producers thread the right externalId value through?" — and the call-site reads are uniformly _identityModelStore.model.externalId (or identityModel.externalId from a captured copy in RebuildUserService), which is the only sensible source. Test fixtures pass null consistently for anonymous cases.

Other factors

  • My two prior review comments (visibility setter, misplaced KDoc) were both addressed in commit 8c5e762 and are marked resolved.
  • Bug hunting system found no issues on this revision.
  • 34-file size is large but every touch is one of: add a parameter, pass through externalId, or update a test fixture. No new control flow, no new branching, no new I/O.
  • This is part of a security-sensitive feature series (Identity Verification), but the security-relevant logic deliberately lives in subsequent PRs, keeping this one a clean reviewable surface for the base-class change.

Base automatically changed from feat/iv-foundation-01 to feat/identity_verification_feature_flagged_major_release May 7, 2026 19:03
@nan-li nan-li changed the title feat(iv): add externalId and requiresJwt to Operation base class (2/6) feat(jwt): add externalId and requiresJwt to Operation base class (2/6) May 7, 2026
@nan-li nan-li merged commit 0e5a610 into feat/identity_verification_feature_flagged_major_release May 7, 2026
4 of 5 checks passed
@nan-li nan-li deleted the feat/iv-oprepo-gating-02 branch May 7, 2026 19:04
@nan-li nan-li mentioned this pull request May 7, 2026
11 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant